Skip to content

fix(navigation): reuse router.prefetch payloads during navigation - #2709

Merged
james-elicx merged 22 commits into
cloudflare:mainfrom
NathanDrake2406:fix/router-prefetch-navigation-reuse
Jul 27, 2026
Merged

fix(navigation): reuse router.prefetch payloads during navigation#2709
james-elicx merged 22 commits into
cloudflare:mainfrom
NathanDrake2406:fix/router-prefetch-navigation-reuse

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Make router.prefetch() results reusable by a subsequent navigation
Core change _appRouter.prefetch resolves the same prefetch policy as <Link> instead of always storing a learning-only entry
Key boundary Policy helpers moved to React-free shims/internal/app-route-prefetch-policy.ts so navigation.ts (no React) and link.tsx ("use client") share one decision
Expected impact router.prefetch(x) + router.push(x) / Link click issues one RSC request instead of two; in-flight programmatic prefetches are shared with navigation

Closes #2707.

Why

Every prefetched payload must either be safe to commit on navigation or be marked learning-only. router.prefetch() hardcoded the learning-only shape ({ cacheForNavigation: false, optimisticRouteShell: true }), so all navigation-reuse paths (findPrefetchCacheEntryForNavigation, consumeMatchedPrefetchResponse, peekPrefetchResponseForNavigation) skipped its entries by design, and every navigation refetched. Next.js reuses router.prefetch() results: it defaults to PrefetchKind.AUTO and caches the full payload for kind: "full" (app-router-instance.ts).

vinext already encodes exactly that reuse decision for <Link> in resolveAutoAppRoutePrefetch / resolveFullAppRoutePrefetch, keyed off the client prefetch route manifest. The right owner of the decision is that shared policy, not a second hardcoded literal in the router, so this PR moves the helpers to an internal module and has router.prefetch consult them.

What changed

Scenario Before After
router.prefetch(x) then navigate to x (route without loading shell / search params) Two RSC requests; prefetch entry never consumed One request; navigation consumes the prefetched snapshot (navigation TTLs, ahead-of-navigation snapshot preparation)
Navigation while router.prefetch(x) is still in flight Second request raced the first Navigation awaits the pending prefetch via the existing consumePrefetchResponseForNavigation path
router.prefetch(x, { kind: "full" }) Option ignored (learning-only) Full-prefetch policy: reusable entry, Next-Router-Prefetch suppressed on the wire (matches <Link prefetch={true}>)
Route with a loading shell / search params, default kind Learning-only Unchanged: learning-only fallback preserves optimistic-route-template learning and matches Next.js AUTO semantics for dynamic routes
No prefetch route manifest match Learning-only fetch Unchanged: an explicit programmatic prefetch still fetches
Interception routes Learning-only Reuse works unchanged through getPrefetchInterceptionContext, which already keys prefetch and navigation identically (covered by the new e2e on the /top/film/[imdbId] modal fixture)
<Link> prefetching, form.tsx prefetching Untouched; link.tsx re-exports the moved helpers so its public test surface is unchanged
Maintainer review path
  1. packages/vinext/src/shims/internal/app-route-prefetch-policy.ts: verbatim extraction from link.tsx (first commit is mechanical only).
  2. packages/vinext/src/shims/navigation.ts (_appRouter.prefetch): the behavioural decision — kind resolution, policy lookup, reusable vs learning branch, freshness gate (discardLearningOnlyPrefetchCacheEntry + hasPrefetchCacheEntryForNavigation), and the reusable prefetchRscResponse behaviour object mirroring Link's.
  3. tests/prefetch-cache.test.ts: unit proof for reuse, in-flight sharing, kind: "full", and the preserved learning-only fallback.
  4. tests/e2e/app-router/router-prefetch-reuse.spec.ts: end-to-end request counting across an intercepted navigation.
Validation
  • New unit tests: reusable entry shape + segment-prefetch headers + no refetch while fresh; navigation awaits an in-flight programmatic prefetch (single fetch); kind: "full" reusable on a loading-shell route with Next-Router-Prefetch absent; loading-shell route stays learning-only under the default kind.
  • New e2e (app-router, dev): router.prefetch + click on the film interception fixture produces exactly one /film/* RSC request and still renders the intercepted panel; a companion no-prefetch test pins the request counter.
  • vp check clean on all touched files.
Commands
vp test run tests/prefetch-cache.test.ts tests/link-navigation.test.ts tests/link.test.ts tests/router-prefetch-invalid-url.test.ts
  4 files, 251 tests passed
PLAYWRIGHT_PROJECT=app-router pnpm test:e2e tests/e2e/app-router/router-prefetch-reuse.spec.ts
  2 passed
PLAYWRIGHT_PROJECT=app-router pnpm test:e2e tests/e2e/app-router/nextjs-compat/prefetch.spec.ts tests/e2e/app-router/interception-dynamic-segment.spec.ts
  14 passed
Risk / compatibility
  • Public API: window.next.router.prefetch gains the documented Next.js kind option; PrefetchOptions.kind stays unknown at the shim boundary and anything other than "full" falls back to auto, matching Next's default: branch.
  • Dev behaviour: programmatic prefetches become reusable in dev too (the manifest is injected in dev; Next.js does not dev-gate router.prefetch). Dev-mode e2e that depend on per-navigation requests are protected by the loading-shell policy and were re-run.
  • Dynamic routes without server stale-time headers keep the same TTL floor as Link full prefetches today (minimumTtlMs: 0 honours server stale-times, PREFETCH_CACHE_TTL fallback; encoded in tests/link-navigation.test.ts stale-time tests).
  • Conflict surface: touches the same link.tsx / prefetchRscResponse region as open PRs fix(app-router): support instant route prefetch shells #2374 and fix(app-router): full prefetch static loading routes #2446. The extraction commit is a verbatim move to keep rebases cheap; this PR depends on neither.
Non-goals
  • Link's fetchLoadingShellForReuse / full-after-shell sequencing, searchAgnosticShell probes, and route-tree gating are not replicated for router.prefetch. Those optimise viewport/hover races that do not apply to a programmatic call; an in-flight router.prefetch entry is already shared with navigation by awaiting entry.pending.
  • No change to Pages Router Router.prefetch or form.tsx prefetching.

References

Reference Why it matters
Issue #2707 Report: router.prefetch + router.push always double-fetches
Next.js app-router-instance.ts (prefetch, lines 392-438) Parity bar: AUTO default, kind: "full", navigation reuse
#2374, #2446 Open PRs touching the same prefetch region (conflict surface only)

link.tsx is a "use client" React module, so its prefetch policy helpers
(resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch and their
supporting trie/route-href logic) could not be reached from navigation.ts,
which must stay importable without React. Move them verbatim into
shims/internal/app-route-prefetch-policy.ts (same layering as
internal/app-route-detection.ts) and name the previously inline policy
shape AppRoutePrefetchPolicy.

link.tsx re-exports canAutoPrefetchFullAppRoute and
resolveAutoAppRoutePrefetch, keeping its public test surface unchanged.
No behaviour change.
router.prefetch() stored every response as a learning-only cache entry
(cacheForNavigation: false), so a router.prefetch(x) followed by
router.push(x) always issued a second RSC request; even an in-flight
programmatic prefetch was invisible to navigation. Next.js reuses
router.prefetch() results (PrefetchKind.AUTO by default, kind: "full"
caches the full payload), and <Link> prefetches in vinext already
produce reusable entries through the shared route-policy helpers.

Resolve the same policy in router.prefetch(): kind: "full" maps to the
full-prefetch policy, anything else to the auto policy for the target
route. Reusable prefetches send the same segment-prefetch headers as
<Link>, gate re-issue on a freshness-aware navigation cache probe
(discarding a stale learning-only twin first), and store the payload
with navigation TTLs plus ahead-of-navigation snapshot preparation.

Routes the auto policy declines (no manifest match, loading shell,
search params) keep the previous learning-only fetch, preserving
optimistic-route-template learning and matching Next.js AUTO semantics
for dynamic routes. In-flight sharing needs no extra machinery:
consumePrefetchResponseForNavigation already awaits a pending entry
once the entry is no longer learning-only.

Fixes cloudflare#2707
Covers issue cloudflare#2707 end to end on the /top -> /film/[imdbId] interception
fixture: a programmatic router.prefetch followed by a Link click must
produce exactly one /film RSC request while still rendering the
intercepted film panel. A companion test without the prefetch pins the
request counter so the reuse assertion cannot pass vacuously.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2709
npm i https://pkg.pr.new/create-vinext-app@2709
npm i https://pkg.pr.new/@vinext/types@2709
npm i https://pkg.pr.new/vinext@2709

commit: 1803208

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 1803208 against base b779754 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 133.2 KB ⚫ +0.5%
Client entry size (gzip) vinext 119.8 KB 120.8 KB ⚫ +0.9%
Dev server cold start vinext 2.76 s 2.78 s ⚫ +0.9%
Production build time vinext 2.87 s 2.88 s ⚫ +0.6%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 179.8 KB 180.3 KB ⚫ +0.3%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a587ba57b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment thread packages/vinext/src/shims/navigation.ts
Comment thread packages/vinext/src/shims/navigation.ts Outdated
…h reuse

Address review findings on the router.prefetch reuse path:

- A configured client rewrite can map the prefetched href onto a
  different App route. The policy previously described the source
  pattern, so a destination with a loading boundary or a
  requires-dynamic flag was incorrectly marked reusable. Load the
  rewrite-aware hybrid module first and resolve the policy (and the
  freshness-gate lookup) against the rewritten destination, mirroring
  Link's prefetchPolicyHref.

- When the freshness gate matched a reusable entry under a normalized
  _rsc variant or rendered-path alias, onInvalidate was attached via an
  exact-key lookup that silently missed, so the documented callback
  never fired on expiry. hasPrefetchCacheEntryForNavigation now attaches
  the callback to whichever entry it matched.

- Import the prefetch policy module dynamically inside the prefetch
  closure so its route-trie dependencies stay off the startup path of
  every next/navigation consumer (AGENTS.md performance guidance).
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: a78870c3ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…tion

Two lifecycle gaps in the router.prefetch navigation-reuse path:

`router.prefetch()` awaits hybrid-route loading, the lazily imported
prefetch policy module, and RSC URL generation before it registers
anything in the prefetch cache. A navigation starting in the same task
finds nothing to share, issues its own request, and the late prefetch
then starts a second one. Track a navigation epoch alongside the
existing link-navigation-start signal and bail out of prefetch setup
when a navigation won the race, mirroring link.tsx's guard.

Consuming a prefetched response deleted the entry with notify=false,
which discarded `onInvalidate` without ever calling it -- so
`prefetch(href, { onInvalidate })` followed by `push(href)` silently
lost the subscription. Retain the callbacks past consumption so they
still fire once when the original stale window elapses or the prefetch
cache is invalidated, matching Next.js's prefetch-task contract. An
entry that aged out before navigation reached it now notifies instead
of dropping the callback.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: d92877083f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

No behavior change.

`<Link>` and `router.prefetch()` carried byte-identical `prepareSnapshot`
closures; extract `prepareNavigationPrefetchSnapshot` and share it.

`resolveAutoAppRoutePrefetch` repeated the same five-field "no prefetch"
literal at four early returns; collapse them to a single
NO_APP_ROUTE_PREFETCH constant. Every call site reads the resolved policy
without mutating or spreading it, so one shared object is safe.

Fold `resolveMatchedAutoAppRoutePrefetch` and its post-hoc search-param
override into one object literal: `prefetchShellFirst` becomes
`hasSearchParams || !isDynamic`, matching the previous unconditional
`true` on the search branch and `!isDynamic` otherwise.

`canAutoPrefetchFullAppRoute` re-ran the window / manifest / href /
trie-match checks before delegating; each of those failures already
resolves to `cacheForNavigation: false`, so drop them.

The policy module stays behind a dynamic import inside the prefetch
closure, keeping its route-trie dependencies off the next/navigation
startup path.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4282cfb662

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment thread packages/vinext/src/shims/navigation.ts
Comment thread packages/vinext/src/shims/navigation.ts Outdated
…idation

Two more lifecycle gaps in the router.prefetch reuse path.

Upgrading a learning-only prefetch to a navigation-reusable one
(`router.prefetch(href)` then `router.prefetch(href, { kind: "full" })`,
or the equivalent <Link> transition) discarded the old entry with
notifications suppressed, so `onInvalidate` callbacks registered by the
first call could never run. A superseded prefetch is dirty in Next.js
terms, so notify instead of dropping. Fixed in the shared discard helper
rather than at the router call site: link.tsx reaches the same path on
the same upgrade. Entries are now collected before deletion so a
subscriber that seeds a new prefetch cannot be visited by the loop that
is still iterating the cache.

The prefetch setup epoch advanced only on navigation start, but
`router.refresh()` clears the caches via `clearNavigationCaches` ->
`invalidatePrefetchCache` without starting a navigation. A closure still
awaiting its policy import would resume afterwards and repopulate a
navigation-reusable entry derived from the pre-refresh generation,
undoing the refresh for that route. Advance the epoch on invalidation
too, and move it next to the cache it guards.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 0027309a8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ated branches

No behavior change.

router.prefetch generated the RSC URL and its rewrite variant with two
sequential awaits over the same headers; run them together.

It also set the mounted-slots header by hand when
createAppPrefetchRequestHeaders already accepts `mountedSlotsHeader`.
The option sets the header for any non-null value while the call site
skipped empty strings, so pass `|| null` to keep an empty slot header
off the wire.

link.tsx built a `full-after-shell` policy literal byte-identical to
resolveFullAppRoutePrefetch(), which the policy extraction had already
made shared and which the file already imports; both arms of the ternary
now resolve the same way, so the branch collapses.

hasPrefetchCacheEntryForNavigation had two byte-identical if bodies and
a local closure that existed only to avoid duplicating their shared
tail. Merge the conditions, and hoist the callback attachment into
attachPrefetchInvalidationToEntry so the key-taking
attachPrefetchInvalidationCallback delegates to it instead of repeating
the "seeded entries need their invalidation timer scheduled" invariant.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60575ba102

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment thread packages/vinext/src/shims/navigation.ts
Low-priority prefetches share a 4-slot queue whose runners do not start
until a previous response body is read. A navigation that reuses an
in-flight prefetch awaits that prefetch's promise, so when the request is
still queued the navigation waits on unrelated response bodies before its
own request starts — indefinitely if one of those streams stalls.

Promote on consume: `consumePrefetchResponseForNavigation` now starts a
queued request instead of waiting for a slot. A promoted request is no
longer a prefetch, it is the navigation, so it bypasses the concurrency
cap. Reaching the queued runner needs a handle the queue did not expose,
so it keeps a WeakMap from the returned promise to its runner, and the
cache entry records the schedulable promise (`entry.pending` is the
derived chain, not the promise the queue knows).

Pre-existing via <Link>: link.tsx passes cacheForNavigation through and
prefetchRscResponse defaults it to true, so low-priority viewport
prefetches already produced reusable entries that navigation awaited.
Fixed in the shared queue rather than the router path so both benefit.

Also drops prefetch cache-entry field assertions that restated the
implementation where the same test already asserted the behavior those
fields produce (consume returning a payload, or null).
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 4c970e02a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Heads up @james-elicx — I was mid-implementation on the same three findings when you pushed 171001112, so I dropped my duplicate and took yours. Two things on top, now in 727e33475:

1. Stripping the fragment needs a same-document guard. toAppPrefetchDestination dropping the hash makes a hash-only navigation compare equal to the route it scrolls within, so router.push("/film/x#cast") while already on /film/x cancelled a pending /film/x prefetch. That navigation issues no RSC request — the planner classifies it as a same-document scroll — so nothing replaced the prefetch it dropped. Same class as the shallow-pushState finding, reached through a different door.

Fixed by skipping cancellation for same-document hash changes. The two rules are complementary: the fragment is still stripped so a real navigation to /film/x#cast supersedes a pending /film/x prefetch.

popstate needed its own entry point. By the time it runs the browser has already applied the history entry, so the pre-navigation URL isHashOnlyBrowserUrlChange needs is gone. It keeps cancelling by destination, which over-cancels a hash-only back/forward — one wasted re-prefetch, never a duplicate request. Noted in a comment rather than silently.

Verified as a browser case, not a unit one: driving a hash navigation in the unit harness needs a DOM it does not have (I wrote the unit version first and it blew up on document is not defined). The e2e case fails when the guard is removed and passes with it; the other five are unaffected.

2. CI was red on 171001112eslint(no-unsafe-optional-chaining) on the (init?.headers as Headers) cast, which vp run check treats as fatal. We landed the same new Headers(init?.headers) fix independently; it merged cleanly.

Local state on the merge: vp run check clean across 1159 files, 266 unit tests green across the four navigation/link files, all 6 router-prefetch-reuse e2e cases pass.

Resolve relative prefetch ownership and policy from the call-time URL, matching the already captured request target. Keep abort controls alive until response bodies are consumed so superseded streaming prefetches release bandwidth and queue slots.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex adversarial review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: ae70329dd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff, ran vp check on the four touched source files (clean) and vp test run tests/prefetch-cache.test.ts (59/59 pass).

This is a well-engineered change. The policy extraction to shims/internal/app-route-prefetch-policy.ts is a faithful, behaviour-preserving move (the prefetchShellFirst: hasSearchParams || !route.isDynamic rewrite is equivalent to the old two-branch form). The hard parts — the abort-aware fetch queue, promotion-on-consume, destination-scoped sticky cancellation, and invalidation-callback retention across consumption — are all consistent and backed by discriminating tests. The concurrency accounting in app-prefetch-fetch-queue.ts balances correctly across the promote/cancel/drain permutations I traced (promote-then-cancel, cancel-then-promote, and drain-after-run all no-op or net-zero the active counter).

The prior Codex rounds surfaced the substantive concerns (rewrite-aware policy, lazy policy import, HAS_CLIENT_REWRITES gating of the ownership preload, epoch to destination scoping, shallow-history and hash-only guards) and they've each been addressed with a regression test. I only have two minor, non-blocking observations below.

// Each callback removes its own record before running, which Set iteration
// tolerates; a record retained by a callback is fired too, which is the
// correct outcome for a full cache invalidation.
for (const retained of retainedPrefetchInvalidations) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking observation: iterating retainedPrefetchInvalidations while fireRetainedPrefetchInvalidation deletes from it is safe, but if a fired onInvalidate callback synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the newly-added record is inserted into the Set during this for...of and will therefore be visited and fired in the same invalidation pass (its TTL effectively ignored). The comment above frames this as "the correct outcome for a full cache invalidation," which is defensible — just flagging that the behaviour depends on insertion timing during iteration. A snapshot copy ([...retainedPrefetchInvalidations]) before the loop would make it timing-independent if that re-entrant case is ever not desired.

*
* No-op when the request has already started or was never queued.
*/
export function promoteAppPrefetchFetch(promise: Promise<Response> | undefined): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking clarity nit: the doc says a promoted request "bypasses the concurrency cap," but the implementation does activeDefaultAppPrefetchRequests += 1 before running (line 158), so a promoted 5th request does consume a slot and pushes the active count to 5 — one over the cap until it releases. That's the correct behaviour; it just isn't literally bypassing the counter, it's temporarily exceeding the cap by one. Worth a one-line tweak so a future reader doesn't expect the counter to be untouched.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

I reviewed PR #2709 (fix(navigation): reuse router.prefetch payloads during navigation) thoroughly:

  • Read the full diff across all 7 changed files
  • Traced the concurrency accounting in the new abort-aware fetch queue (app-prefetch-fetch-queue.ts) across promote/cancel/drain permutations
  • Verified the destination-scoped sticky cancellation, hash-only/shallow-history guards, and invalidation-callback retention logic in navigation.ts
  • Confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving
  • Ran vp check on the four touched source files (clean) and vp test run tests/prefetch-cache.test.ts (59/59 pass)
  • Confirmed the e2e fixtures (#godfather-film-link, film-panel) exist and the base URL matches the app-router e2e convention

Findings: No blocking issues. The substantive concerns were already caught and fixed across the many prior Codex rounds (each with a regression test). I posted a COMMENT review with two minor, non-blocking observations:

  1. invalidatePrefetchCache re-entrancy — a callback that synchronously seeds a new retained invalidation during the for...of will be fired in the same pass; documented as intentional but timing-dependent. A snapshot copy would make it timing-independent.
  2. Comment clarity in promoteAppPrefetchFetch — the doc says it "bypasses the concurrency cap," but it actually increments the active counter (temporarily exceeding the cap by one), which is the correct behaviour but slightly at odds with the wording.

Both are nits; the PR is approvable-quality work.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff across all 7 files, traced the abort-aware queue accounting (app-prefetch-fetch-queue.ts) through promote/cancel/drain permutations, verified the destination-scoped sticky cancellation and hash-only/shallow-history guards in navigation.ts, and confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving (prefetchShellFirst: hasSearchParams || !route.isDynamic and the cacheForNavigation composition both reduce to the old two-branch form). Fixtures (#godfather-film-link, film-panel, no loading.tsx on /film/[imdbId]) all line up with the e2e expectations.

This is careful, well-tested work and the substantive concurrency/lifecycle concerns were already surfaced and fixed with discriminating regression tests in the prior rounds. I confirmed a few things that could have been bugs but are actually correct:

  • The abort-mid-stream path (cancelAppPrefetchFetch on a resolved-but-streaming response) does not leak a queue slot: snapshotRscResponse's finally releases the slot even when response.arrayBuffer() rejects on abort, so the outer .catch not calling releaseAppPrefetchFetchSlot is fine.
  • Double-promote and promote-after-start are both no-ops (indexOf(runner) === -1), so the active counter stays balanced.
  • The new release-key on high-priority responses only deletes the control map entry and never touches activeDefaultAppPrefetchRequests, so high-priority accounting is unchanged.
  • The setup token is registered synchronously before the async closure, so a same-task router.push(x) reliably cancels a same-task router.prefetch(x).

No blocking issues. Two minor, non-blocking observations inline.

* promise. When the request is only queued, the navigation would otherwise wait
* for unrelated prefetch response bodies to finish before its own request even
* starts — indefinitely if one of those streams stalls. A promoted request is
* no longer a prefetch, it is the navigation, so it bypasses the concurrency

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking wording nit: the doc says a promoted request "bypasses the concurrency cap instead of waiting for a slot," but the implementation does activeDefaultAppPrefetchRequests += 1 (line 158) before running, so a promoted 5th request consumes a slot and pushes the active count to 5 — one over the cap until it releases. The behaviour is correct (it's counted so the next drainDefaultAppPrefetchQueue won't over-schedule), it just doesn't literally bypass the counter. Worth a one-line clarification so a future reader doesn't expect the counter to be left untouched.

deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true);
}
prefetched.clear();
// Each callback removes its own record before running, which Set iteration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking observation on invalidatePrefetchCache: iterating retainedPrefetchInvalidations while fireRetainedPrefetchInvalidation deletes from it is safe, but if a fired onInvalidate callback synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the newly-inserted record is added to the Set during this for...of and will therefore be visited and fired in the same pass, ignoring its TTL. The comment frames this as "the correct outcome for a full cache invalidation," which is defensible, but the behaviour is timing-dependent on insertion-during-iteration. A snapshot copy (for (const retained of [...retainedPrefetchInvalidations])) would make it deterministic if that re-entrant case is ever unwanted.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

I reviewed PR #2709 (fix(navigation): reuse router.prefetch payloads during navigation) thoroughly:

  • Read the full diff across all 7 changed files
  • Traced the abort-aware fetch queue accounting (app-prefetch-fetch-queue.ts) across promote/cancel/drain permutations
  • Verified the destination-scoped sticky cancellation, hash-only/shallow-history guards, and invalidation-callback retention in navigation.ts
  • Confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving (both prefetchShellFirst and cacheForNavigation reduce to the old two-branch form)
  • Verified fixtures (#godfather-film-link, film-panel, no loading.tsx on /film/[imdbId]) match the e2e expectations

Findings: No blocking issues. I specifically checked for bugs and found several near-misses that are actually correct:

  • The abort-mid-stream path does not leak a queue slot — snapshotRscResponse's finally releases it even when arrayBuffer() rejects on abort
  • Double-promote / promote-after-start are no-ops via indexOf(runner) === -1, keeping the active counter balanced
  • The new high-priority release-key only cleans up the control map; accounting is unchanged
  • The setup token is registered synchronously before the async closure, so same-task push(x) reliably cancels same-task prefetch(x)

I posted a COMMENT review with two minor, non-blocking observations:

  1. promoteAppPrefetchFetch doc wording — says it "bypasses the concurrency cap" but it increments the active counter (temporarily exceeding cap by one), which is the correct behaviour but at odds with the wording.
  2. invalidatePrefetchCache re-entrancy — a callback that synchronously seeds a retained invalidation during the for...of is fired in the same pass, ignoring its TTL; a snapshot copy would make it timing-independent.

The substantive concurrency/lifecycle concerns were already caught and fixed across the prior Codex rounds, each with a regression test. The PR is approvable-quality work.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff across all 7 files. Traced the abort-aware fetch queue accounting in app-prefetch-fetch-queue.ts (promote / cancel / drain permutations all balance the active counter), verified the destination-scoped sticky cancellation and hash-only / shallow-history guards in navigation.ts, and confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving.

Policy extraction is faithful. The old resolveAutoAppRoutePrefetch special-cased search params in a trailing if (url.search !== "") branch (cacheForNavigation: false, prefetchShellFirst: true); the new form folds that into cacheForNavigation: !hasSearchParams && !canPrefetchLoadingShell && requiresDynamicNavigationRequest !== true and prefetchShellFirst: hasSearchParams || !route.isDynamic. Both reduce to the original two-branch result for every input, and minimumTtlMs / fallbackTtl are unchanged. canAutoPrefetchFullAppRoute dropping its redundant pre-match check is equivalent because resolveAutoAppRoutePrefetch already returns NO_APP_ROUTE_PREFETCH (cacheForNavigation: false) on every no-match path.

Header parity with <Link> holds. The reusable auto path sets Next-Router-Prefetch: 1 + segment header, matching Link's mode === "auto" branch; the kind: "full" path deletes Next-Router-Prefetch, matching <Link prefetch={true}>; the learning-only fallback preserves the original router.prefetch header shape (Next-Router-Prefetch: 1, no segment header). The mounted-slots header now flows through the createAppPrefetchRequestHeaders option instead of a separate .set(), which is equivalent (empty string still skipped).

Queue accounting is sound. Promote-then-cancel, cancel-then-promote, and drain-after-promote all no-op or net-zero the active counter (indexOf(runner) === -1 guards double-promote / promote-after-start). The abort-mid-stream path releases the slot via snapshotRscResponse's finally, and the .finally in prefetchRscResponse clears fetchPromise alongside pending so a settled entry can't be promoted.

vp-style unit run: vitest run tests/prefetch-cache.test.ts → 59/59 pass. Fixtures (#godfather-film-link, film-panel, no loading.tsx on /film/[imdbId]) line up with the e2e expectations.

No blocking issues. Two minor, non-blocking observations inline (both already noted in prior rounds).

// Each callback removes its own record before running, which Set iteration
// tolerates; a record retained by a callback is fired too, which is the
// correct outcome for a full cache invalidation.
for (const retained of retainedPrefetchInvalidations) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, re-flagging the prior observation: fireRetainedPrefetchInvalidation deletes each record before firing, so plain iteration is safe. But if a fired onInvalidate synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the freshly-inserted record lands in the Set during this for...of and — per ES Set-iteration semantics — is visited and fired in the same pass, ignoring its TTL. The comment frames this as the correct outcome for a full invalidation, which is defensible; it's just timing-dependent on insertion-during-iteration. A snapshot copy (for (const retained of [...retainedPrefetchInvalidations])) would make it deterministic if the re-entrant case is ever unwanted.

* promise. When the request is only queued, the navigation would otherwise wait
* for unrelated prefetch response bodies to finish before its own request even
* starts — indefinitely if one of those streams stalls. A promoted request is
* no longer a prefetch, it is the navigation, so it bypasses the concurrency

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking wording nit: the doc says a promoted request "bypasses the concurrency cap instead of waiting for a slot," but the implementation does activeDefaultAppPrefetchRequests += 1 (line 158) before running, so a promoted 5th request consumes a slot and temporarily pushes the active count to 5 — one over the cap until it releases. The behaviour is correct (it's counted, so the next drainDefaultAppPrefetchQueue won't over-schedule); it just doesn't literally leave the counter untouched. Worth a one-line clarification so a future reader doesn't expect the counter to be bypassed.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully to PR #2709.

Summary

I reviewed PR #2709 (fix(navigation): reuse router.prefetch payloads during navigation) thoroughly:

  • Read the full diff across all 7 changed files
  • Verified the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving by diffing against the original link.tsx logic — the search-param special-case folds cleanly into the new cacheForNavigation / prefetchShellFirst expressions, and canAutoPrefetchFullAppRoute dropping its redundant pre-match check is equivalent
  • Confirmed header parity with <Link> for the auto-reusable, kind: "full", and learning-only fallback paths, including the mounted-slots header now routed through the createAppPrefetchRequestHeaders option
  • Traced the abort-aware fetch queue accounting (app-prefetch-fetch-queue.ts) across promote/cancel/drain permutations — all balance the active counter, no slot leaks
  • Verified the destination-scoped sticky cancellation and the hash-only/shallow-history guards
  • Ran vitest run tests/prefetch-cache.test.ts → 59/59 pass; confirmed the e2e fixtures exist and /film/[imdbId] has no loading.tsx

Findings: No blocking issues. The PR has been through an exceptional number of prior review rounds (many Codex + bonk cycles), and every substantive concurrency/lifecycle concern (rewrite-aware policy, lazy import, epoch → destination scoping, shallow-history/hash-only guards, queue promotion-on-consume, invalidation-callback retention) has already been addressed with a discriminating regression test.

I posted a COMMENT review with two minor, non-blocking observations (both previously noted): the invalidatePrefetchCache re-entrancy timing dependency, and the promoteAppPrefetchFetch doc wording that says it "bypasses the concurrency cap" when it actually increments the counter. Both are nits; this is approvable-quality work.

github run

@james-elicx
james-elicx merged commit bf2d7ac into cloudflare:main Jul 27, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App Router: router.prefetch() responses are never reused by navigation (learning-only cache entry) — every push after prefetch re-fetches

2 participants